All files / src/app/api/sessions/[id]/decisions/batch route.ts

91.78% Statements 67/73
89.47% Branches 34/38
100% Functions 6/6
90.76% Lines 59/65

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236                                            3x 3x                 58x 1x       57x       35x 35x 35x   34x   34x       34x 34x 33x 33x       32x 1x                   31x 1x                     30x 30x   1x 1x                       29x 29x 1x     28x         28x         28x   28x       28x                 57x         28x               58x       28x         28x 28x       28x 28x   28x 58x 6x 6x   52x     6x 6x   46x       46x                               46x       46x 46x 46x             28x 19x             19x 33x       28x           28x 58x   28x                        
import { NextRequest, NextResponse } from "next/server";
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import { getDb } from "@/lib/db";
import { decisionEventsTable, itemsTable } from "@/lib/db/schema";
import { generateId } from "@/lib/id";
import { getSession } from "@/lib/auth";
import { withTransaction, lockCollectionCurator } from "@/lib/transactions";
import { requireApiCollectionAccess } from "@/server/dal/collection-access";
import { SessionController } from "@/server/api/sessions";
import { PreferenceController } from "@/server/api/preferences";
import { DispositionController } from "@/server/api/dispositions";
import {
  BatchRequestSchema,
  MAX_BATCH_BYTES,
  type BatchEntry,
  type BatchResult,
} from "@/lib/decision-batch-schema";
import { readJsonWithLimit, BodyTooLargeError } from "@/lib/read-json-with-limit";
 
// The recompute-under-lock write path uses node-postgres — pin the Node runtime
// (not Edge) and give the platform an explicit, generous function cap so a slow
// recompute can't be aborted mid-transaction and roll back (and lose) the batch.
export const runtime = "nodejs";
export const maxDuration = 30;
 
// Semantic validation (spec §2.3.2): an entry referencing an item outside the
// collection's live set — EITHER side of a compare pair — is rejected, never
// written. Closes, for the batch route, the gap the single route still has
// (purses-stln).
function referencesUnknownItem(entry: BatchEntry, liveItemIds: Set<string>): boolean {
  // A compare pair references two items — either side unknown rejects the entry.
  // Single-item decisions (triage, usage) reference exactly one.
  if (entry.type === "pairwise_compare") {
    return (
      !liveItemIds.has(entry.decision.itemAId) || !liveItemIds.has(entry.decision.itemBId)
    );
  }
  return !liveItemIds.has(entry.decision.itemId);
}
 
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const session = await getSession();
  if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  // Capture once: the `session.user` narrowing above is lost inside the tx closure.
  const userId = session.user.id;
 
  const db = getDb();
  // Authorize BEFORE any read of the body: the session must belong to a
  // collection the user can access. getSessionOrNull distinguishes absent
  // (→404) from a real DB error (→ propagates, 500).
  const sortSession = await SessionController.fromDb(db).getSessionOrNull(id);
  if (!sortSession) return NextResponse.json({ error: "Not found" }, { status: 404 });
  const access = await requireApiCollectionAccess(db, sortSession.collectionId, userId);
  if (access instanceof NextResponse) return access;
 
  // purses-t9l: each user sorts independently — you may only record decisions in
  // your OWN session. Cross-curator triage is forbidden, blocked before any write.
  if (sortSession.curatorId !== userId) {
    return NextResponse.json(
      { error: "You can only record decisions in your own sorting session" },
      { status: 403 },
    );
  }
 
  // duv4: a decision may only be recorded on an IN-PROGRESS session (active or
  // paused — the states the partial unique index treats as holding the slot). A
  // completed/abandoned/soft-deleted session is terminal; reject a late batch
  // with 409 so it can't mutate a finished session's preference set.
  if (sortSession.status !== "active" && sortSession.status !== "paused") {
    return NextResponse.json(
      { error: "This sorting session is no longer in progress" },
      { status: 409 },
    );
  }
 
  // Body cap is STREAM-enforced (spec F17): App Router has no
  // bodyParser.sizeLimit and Content-Length is absent under chunked transfer,
  // so the reader budgets bytes as they arrive and cancels the stream the
  // moment the budget is exceeded — 413. Malformed JSON under budget → 400.
  let raw: unknown;
  try {
    raw = await readJsonWithLimit(req, MAX_BATCH_BYTES);
  } catch (error) {
    Eif (error instanceof BodyTooLargeError) {
      return NextResponse.json({ error: "Request body too large" }, { status: 413 });
    }
    if (error instanceof SyntaxError) {
      return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
    }
    throw error;
  }
 
  // sxfo: whole-batch boundary validation with the shared client/server schema
  // — one out-of-contract entry rejects the whole batch with 400 BEFORE the
  // transaction, so junk never persists (the client pre-validates with the
  // same module, so a 400 here means a stale/foreign caller).
  const parsed = BatchRequestSchema.safeParse(raw);
  if (!parsed.success) {
    return NextResponse.json({ error: "Invalid request" }, { status: 400 });
  }
 
  try {
    // Atomic: all entry inserts, the single preference recompute, and the
    // triage disposition clears commit or roll back together — a partial batch
    // would leave items both ranked and disposed. Every write uses the
    // tx-bound handle.
    const results = await withTransaction(db, async (tx) => {
      // §2.3.1 — duv4: the SAME (collection,curator) advisory lock the single
      // decisions route and setDisposition take — serializes every preference
      // recompute for this curator so batches, single taps, and dispositions
      // can never interleave.
      await lockCollectionCurator(tx, sortSession.collectionId, userId);
 
      const entries = parsed.data.decisions;
 
      // §2.3.2 — semantic validation: ONE indexed query for the collection's
      // live item ids; every entry is classified against the set in memory.
      const liveRows = await tx
        .select({ id: itemsTable.id })
        .from(itemsTable)
        .where(
          and(
            eq(itemsTable.collectionId, sortSession.collectionId),
            isNull(itemsTable.deletedAt),
          ),
        );
      const liveItemIds = new Set(liveRows.map((r) => r.id));
 
      // §2.3.3 — cross-request idempotency: ONE SELECT of the batch's keys that
      // already committed for this session. The advisory lock serializes all
      // writers, so this read is authoritative.
      const existingRows = await tx
        .select({ clientKey: decisionEventsTable.clientKey })
        .from(decisionEventsTable)
        .where(
          and(
            eq(decisionEventsTable.sessionId, id),
            inArray(
              decisionEventsTable.clientKey,
              entries.map((e) => e.clientKey),
            ),
          ),
        );
      const committedKeys = new Set(existingRows.map((r) => r.clientKey));
 
      // §2.3.4/5 — classify in array order; insert survivors with generateId's process-wide monotonic
      // ids (later array entries get strictly greater ids, so at the shared transaction timestamp the
      // (createdAt, id) comparator resolves in-batch same-item conflicts to the later entry).
      const seenKeys = new Set<string>();
      const insertedTriagedItemIds = new Set<string>();
      // §2.4 (Epic R) — count only inserted SCORING entries (triage/compare, not
      // usage): usage rides this route but is dark to the scorer. 201/200 still
      // derives from counts.created (any-type), so no separate inserted counter.
      let insertedScoringCount = 0;
      const results: BatchResult[] = [];
 
      for (const entry of entries) {
        if (referencesUnknownItem(entry, liveItemIds)) {
          results.push({ clientKey: entry.clientKey, status: "rejected", reason: "unknown_item" });
          continue;
        }
        if (committedKeys.has(entry.clientKey) || seenKeys.has(entry.clientKey)) {
          // In-batch dedupe: first occurrence proceeds, later ones are
          // duplicates — same semantics as a replay.
          results.push({ clientKey: entry.clientKey, status: "duplicate" });
          continue;
        }
        seenKeys.add(entry.clientKey);
        // Defense-in-depth: ON CONFLICT on the partial unique index — NEVER a
        // mid-transaction re-read, which would die with 25P02 inside an
        // aborted transaction. An absent RETURNING row → duplicate.
        const inserted = await tx
          .insert(decisionEventsTable)
          .values({
            id: generateId("event"),
            sessionId: id,
            type: entry.type,
            decision: entry.decision,
            clientKey: entry.clientKey,
            // createdAt OMITTED — defaultNow(): one shared timestamp per
            // batch, same clock domain as the single route (spec §2.3.5).
          })
          .onConflictDoNothing({
            target: [decisionEventsTable.sessionId, decisionEventsTable.clientKey],
            where: sql`${decisionEventsTable.clientKey} is not null`,
          })
          .returning({ id: decisionEventsTable.id });
        Iif (inserted.length === 0) {
          results.push({ clientKey: entry.clientKey, status: "duplicate" });
          continue;
        }
        if (entry.type !== "usage") insertedScoringCount++;
        if (entry.type === "triage") insertedTriagedItemIds.add(entry.decision.itemId);
        results.push({ clientKey: entry.clientKey, status: "created" });
      }
 
      // §2.3.6 / §2.4 — ONE recompute, iff ≥1 *scoring* entry was inserted (Epic
      // P §2.3.6's anticipated extension). A usage-only, all-duplicate, or
      // all-rejected batch inserts nothing scoring, so it must NOT rewrite the
      // preference set or clear a disposition — usage is dark to both.
      if (insertedScoringCount > 0) {
        await PreferenceController.fromDb(tx).recalculatePreferences(
          sortSession.collectionId,
          userId,
        );
 
        // §2.3.7 — disposed <=> untriaged: clear once per distinct triaged
        // item among the INSERTED entries only — never for duplicate/rejected.
        for (const itemId of insertedTriagedItemIds) {
          await DispositionController.fromDb(tx).bringBack(itemId, userId);
        }
      }
 
      return results;
    }, {
      op: "record_decision_batch",
      ctx: { collectionId: sortSession.collectionId, curatorId: userId, sessionId: id },
    });
 
    const counts = { created: 0, duplicate: 0, rejected: 0 };
    for (const r of results) counts[r.status]++;
 
    return NextResponse.json({ results }, { status: counts.created > 0 ? 201 : 200 });
  } catch (error) {
    // The failure is already observed at the withTransaction seam (op:
    // "record_decision_batch" + ctx), which logs [db_error] ONCE and rethrows
    // — this catch only maps to a 500 and never re-logs.
    // Never echo the error: it carries driver text (purses-hiez).
    return NextResponse.json(
      { error: "Failed to record decisions" },
      { status: 500 },
    );
  }
}